feat(webmcp): let agents drive the app instead of posting behind it - #12
Merged
Conversation
Every tool posted straight to /api/*, so nothing on screen moved: an agent approving an expense left the user looking at a row that still read "Submitted". ToolRegistry.observe() refreshes only Session.pendingApprovals, whose sole consumer is ToolSession gating a tool, and every page's resource() params is signal-free, so nothing could re-trigger a load. A human cannot add an expense without going to /add, or change a budget without going to /budgets. Now neither can an agent. - page-actions.ts is the rendezvous: a page publishes an action while mounted, a tool waits for it. No API fallback on timeout, deliberately — a fallback would restore the invisible path this removes, and only when something went wrong. Unregister clears only the handler it installed, since Angular builds the incoming component before destroying the outgoing one. - page-driven-tools.ts holds every write and injects no ApiClient. Each one navigates to the owning page, waits, and hands over the arguments; the page does the work through the same method its own buttons call, so the row patching, form messages and reloads all apply unchanged. - set_budget is new. POST/PATCH /budgets and the form already existed; only the tool was missing, so an agent could read a budget and never change one. - navigate_to moves the browser between the seven authenticated pages, with enum descriptions that double as the map of the app an agent reads off getTools(). Pinned against the real router config in both directions. - The Cambiaro converter is now a card on the dashboard, open on arrival: a cross-origin tool lives only as long as the document that registered it, so a collapsed frame meant the Copilot could not convert until someone clicked. The /add handler does not use form.requestSubmit(): onSubmit stamps the audit row actor: agentInvoked ? 'agent' : 'human', and a synthetic submit carries no agentInvoked, so it would file the agent's work as a person's. That flag is the only thing that can produce actor: 'human'. The fill is paced and the Copilot collapses to the orb below sm, where the panel is fixed inset-0 and would hide the page it is driving.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every WebMCP tool posted straight to
/api/*, so nothing on screen moved. Anagent approving an expense left the user looking at a row that still read
"Submitted".
ToolRegistry.observe()refreshes onlySession.pendingApprovals,whose sole consumer is
ToolSessiongating a tool — no page reads it — andevery page's
resource()paramsis signal-free, so no signal change couldre-trigger a load either.
A human cannot add an expense without going to
/add, or change a budgetwithout going to
/budgets. Now neither can an agent.What
Writes go through the page.
submit_expense,approve_expenseand the newset_budgetlive intools/page-driven-tools.ts, which injects noApiClient. Each navigates to the owning page, waits for it to mount, andhands over the arguments. The page performs the work through the same method its
own buttons call — so the optimistic row patching, the form messages, the
validation and the reloads all apply with no new plumbing.
submit_expense/addapprove_expense/expensesrun()set_budget/budgetsset_budgetis new.POST/PATCH /budgetsand the create/edit form bothalready existed; only the tool was missing, so an agent could read a budget
with
get_budget_statusand never change one.navigate_tomoves the browser between the seven authenticated pages. Itsenum descriptions double as the map of the app an agent reads straight off
getTools(), instead of reading the DOM and guessing where to click.The Cambiaro converter is now a card on the dashboard, open on arrival. A
cross-origin tool lives only as long as the document that registered it, so a
collapsed frame on the landing screen meant the Copilot had no
convertCurrencyuntil someone clicked. One mount only — two frames would publish the tool twice.
Details worth a reviewer's attention
PageActionshas no API fallback on timeout, deliberately. A fallbackwould restore exactly the invisible path this removes, and only when something
went wrong — a slow chunk, a guard redirect. A timeout is an error the model
reports.
incoming component before destroying the outgoing one, so an unconditional
delete lets a page being torn down wipe the incoming page's registration.
/addhandler does not useform.requestSubmit().onSubmitstampsthe audit row
actor: agentInvoked ? 'agent' : 'human', and a syntheticsubmit carries no
agentInvoked— it would file the agent's work as aperson's. That flag is the only thing in the app that can produce
actor: 'human', and the audit viewer's contrast is built on it.core/agent/fill-pacing.ts). Filling and submitting inone frame leaves no frame in which the filled form is on screen — identical to
the invisible POST it replaced. Specs drive the stagger at 0.
sm, where the panel isfixed inset-0and would hide the page it is driving. Collapses for the turn,not per call.
submit_expensestill means create and submit for approval; thedeclarative
add_expense_formcreates a draft only, so the handler performsthe transition rather than quietly narrowing the contract.
Verification
1,051 tests pass — 12 shared, 149 backend, 890 frontend (34 new). Clean
production build and prerender. App hydrates with no console errors after
pnpm run dev:clean(required:SET_BUDGETis a new@actuo/sharedexport).Not yet verified: the signed-in walkthrough — logging an expense, approving
it, and setting a budget through the Copilot, plus the mobile collapse at
< 640px.